Skip to content

feat(nodes): rich node cards with rename, forget, and tray cleanup - #324

Merged
shanselman merged 2 commits into
openclaw:masterfrom
bkudiess:feature/nodes-page-details
May 13, 2026
Merged

feat(nodes): rich node cards with rename, forget, and tray cleanup#324
shanselman merged 2 commits into
openclaw:masterfrom
bkudiess:feature/nodes-page-details

Conversation

@bkudiess

Copy link
Copy Markdown
Collaborator

Summary

Tray-app changes covering four areas of how the operator manages paired nodes:

  1. Tray flyout — the Devices section now shows only currently-connected nodes. Stale paired-but-offline entries no longer accumulate in the right-click menu; they remain accessible on the full Nodes page.

  2. Gateway model + parserGatewayNodeInfo now carries the rest of the NodeListNode schema the gateway already sends (Version, CoreVersion, UiVersion, ClientId, ClientMode, DeviceFamily, ModelIdentifier, RemoteIp, PathEnv, ConnectedAt, ApprovedAt, LastSeenReason, IsPaired, HasExplicitDisplayName, DisabledCommands). ParseNodeList now reads the production *Ms timestamp wire names; legacy non-Ms fallbacks remain for mocks/tests. LastSeen no longer falls back to connectedAtMs so the same value never appears twice in the UI.

  3. New gateway client methods:

    • NodeRenameAsync → returns NodeRenameResult (success/error message)
    • NodePairRemoveAsync → returns NodeForgetResult (success/error message)

    Both use SendWizardRequestAsync rather than the fire-and-forget TrySendTrackedRequestAsync, so scope rejections, missing nodeIds, and other application-level failures are reported back to the caller instead of being silently swallowed. The node.pair.resolved broadcast handler now refreshes both the pair list and the node list.

  4. NodesPage — full rewrite of the per-node card. Each node renders as an Expander (online auto-expanded, offline collapsed) with a body that shows the identity row, version line, hardware, network, timestamps, capability tags (now actually populated), commands list with disabled annotations, permissions grid, and an optional collapsed PATH dump. Action footer at the bottom — separator + a right-aligned Rename / Forget pair — follows the Win11 Settings pattern (Manage / Remove on Email account cards). Both actions open ContentDialogs using the deferral pattern so failures stay inline instead of silently closing.

Implementation notes

  • Click lambdas wrap their async work in try/catch so an unhandled exception in a dialog flow can never become an async void crash.
  • Rename TextBox pre-fills with the explicit display name only — never with the parser's fallback id — so pressing Enter on an unnamed node doesn't persist the id as the new display name.
  • Layout uses Grid with star/auto columns where TextWrapping and TextTrimming actually matter (header, identity row, label rows); long ids/names ellipsize with a tooltip, long values wrap.
  • ModelFormatting.FormatAge was made public and grew a clock-skew guard plus a >30d → absolute date branch; both the header DetailText and the body timestamps now share it, so a single timestamp can't show as "1d ago" in one place and "36h ago" in another.
  • Forget dialog defaults to Cancel so Enter doesn't accidentally destroy a pairing; Rename defaults to Primary so Enter confirms.
  • All UI updates flow from gateway responses — no optimistic local mutations on rename or forget.
  • 25 new resource keys per locale (en-us, fr-fr, nl-nl, zh-cn, zh-tw); Version, Hardware, and PATH are registered as invariant.

Tests

  • OpenClaw.Shared.Tests+11 tests covering the full NodeListNode schema, legacy wire-name compatibility, minimal-payload defaults, and rename/forget input validation paths. Total: 1547 passed, 28 skipped, 0 failed.
  • OpenClaw.Tray.Tests — mock implementation of IOperatorGatewayClient updated for the new methods. Localization validation tests pass with the three invariant exceptions registered. Total: 965 passed, 0 failed.
  • dotnet build clean.

Screenshots

The Devices section in the tray flyout no longer lists offline duplicates. Each Nodes page card now shows a full Expander with rename/forget at the bottom. (Manual smoke tested against a live gateway.)

Known follow-ups (out of scope for this PR)

  • Scope-aware action gating — Rename and Forget buttons are not yet disabled when the session lacks operator.pairing. Bootstrap-token sessions will see enabled buttons that fail with the gateway's "missing scope" message inline — same as the existing pair approve/reject buttons. A wider scope-aware UI pass should expose granted scopes on IOperatorGatewayClient and gate everywhere consistently.
  • Capability badge wrap — capability pills currently use a horizontal StackPanel (no WrapPanel in WinUI 3 base). Real but low-impact; defer until a paired node with many caps surfaces overflow.
  • Tray "+N more" overflow — quality-of-life link when more than 5 nodes are online.

Validation checklist

  • ./build.ps1 clean
  • dotnet test ./tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
  • dotnet test ./tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
  • Manual smoke against a live gateway

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Tray-app changes covering four areas of how the operator manages paired
nodes:

1. Tray flyout — the "Devices" section now shows only currently-connected
   nodes. Stale paired-but-offline entries no longer accumulate in the
   right-click menu; they remain accessible on the full Nodes page.

2. Gateway model + parser — `GatewayNodeInfo` now carries the rest of the
   `NodeListNode` schema the gateway already sends: `Version`,
   `CoreVersion`, `UiVersion`, `ClientId`, `ClientMode`, `DeviceFamily`,
   `ModelIdentifier`, `RemoteIp`, `PathEnv`, `ConnectedAt`, `ApprovedAt`,
   `LastSeenReason`, `IsPaired`, `HasExplicitDisplayName`, plus the
   `DisabledCommands` list. `ParseNodeList` now reads the production
   `*Ms` timestamp wire names (`lastSeenAtMs`, `connectedAtMs`,
   `approvedAtMs`); the legacy non-Ms fallbacks remain for mocks/tests.
   `LastSeen` no longer falls back to `connectedAtMs` so the same value
   doesn't appear twice in the UI.

3. New gateway client methods:
   - `NodeRenameAsync` — awaits the gateway response via
     `SendWizardRequestAsync` and returns a `NodeRenameResult` with
     success/error so the UI can surface the actual server message.
   - `NodePairRemoveAsync` — same pattern, returns a `NodeForgetResult`.
     Using `SendWizardRequestAsync` (rather than the fire-and-forget
     `TrySendTrackedRequestAsync`) means scope rejections, missing
     nodeIds, and other application-level failures are reported back to
     the caller instead of being silently swallowed.
   The `node.pair.resolved` broadcast handler now refreshes both the
   pair-list and the node list so removed nodes disappear immediately.

4. NodesPage — full rewrite of the per-node card. Each node renders as
   an `Expander` (online auto-expanded, offline collapsed) with a body
   that shows the identity row, version line, hardware, network,
   timestamps, capability tags (now actually populated), commands list
   with disabled annotations, permissions grid, and an optional
   collapsed PATH dump. Action footer at the bottom — separator + a
   right-aligned [Rename] [Forget] pair — follows the Win11 Settings
   pattern (Manage / Remove on Email account cards). Both actions open
   `ContentDialog`s using the deferral pattern so failures stay inline
   instead of silently closing.

Implementation notes worth highlighting:
- Click lambdas wrap their async work in try/catch so an unhandled
  exception in a dialog flow can never become an `async void` crash.
- Rename TextBox pre-fills with the explicit display name only — never
  with the parser's fallback id — so pressing Enter on an unnamed node
  doesn't persist the id as the new display name.
- Layout uses `Grid` with star/auto columns where `TextWrapping` and
  `TextTrimming` actually matter (header, identity row, label rows);
  long ids/names ellipsize with a tooltip, long values wrap.
- `ModelFormatting.FormatAge` was made `public` and grew a clock-skew
  guard plus a >30d absolute-date branch; both the header `DetailText`
  and the body timestamps now share it, so a single timestamp can't
  show as "1d ago" in one place and "36h ago" in another.
- 25 new resource keys per locale (en-us, fr-fr, nl-nl, zh-cn, zh-tw);
  "Version", "Hardware", and "PATH" are registered as invariant.

Tests:
- `OpenClaw.Shared.Tests` adds 7 new parser tests covering the full
  `NodeListNode` schema, legacy wire-name compatibility, minimal
  payload defaults, and rename/forget input validation paths.
- `OpenClaw.Tray.Tests` mock implementation of `IOperatorGatewayClient`
  updated for the new methods. Localization validation tests pass with
  the three invariant exceptions registered.

Validation: `dotnet build` clean; `OpenClaw.Shared.Tests` 1547 passed,
`OpenClaw.Tray.Tests` 965 passed.

Known follow-up (out of scope for this PR, deserves its own change):
- Action buttons are not yet gated on `operator.pairing` scope.
  Bootstrap-token sessions will see enabled buttons that fail with the
  gateway's "missing scope" message inline — same as the existing pair
  approve/reject buttons. A wider scope-aware UI pass should expose
  granted scopes on `IOperatorGatewayClient` and gate everywhere.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@shanselman

Copy link
Copy Markdown
Collaborator

Review feedback from adversarial PR #324 pass

I reviewed this with a critical eye against the tray/gateway interaction paths, and also cross-checked it with a two-model review. Overall this looks solid: the PR is clean/mergeable, CI is green, the gateway parser changes line up with the richer NodeListNode shape, and the rename/forget RPC paths correctly wait for gateway ack/error responses instead of treating WS send as success.

I do recommend addressing one small WinUI dialog reentrancy issue before merge. The remaining items are follow-up/polish level.

Should fix before merge: page-wide ContentDialog guard

NodesPage currently tracks open dialogs by node id:

  • _nodesWithDialogOpen is a HashSet<string> keyed by node.NodeId.
  • OnRenameClickedAsync and OnForgetClickedAsync only block another dialog for the same node.
  • Both methods then call await dialog.ShowAsync().

That prevents double-clicking Rename/Forget for one node, but it does not prevent a second dialog for a different node from being shown while the first dialog is still opening/open. WinUI only allows one ContentDialog per XamlRoot; this repo already documents that exact failure mode in SandboxPage.xaml.cs around the sandbox confirmation guard (Cannot show another dialog until the previous one is dismissed).

Impact: a fast user path like Rename on node A, then Forget on node B can make the second ShowAsync() throw/fail. The outer click-handler catch appears to keep this from crashing the app, but the action silently fails with no user feedback.

Suggested fix: make the guard page-wide instead of per-node, or at least check the set count before adding the current node:

if (_nodesWithDialogOpen.Count > 0) return;
if (!_nodesWithDialogOpen.Add(node.NodeId)) return;

A clearer alternative would be a dedicated _dialogOpen boolean similar to SandboxPage's _confirmDialogOpen. This is a small, high-confidence fix and should get us back to merge confidence.

Follow-up / polish items

  1. Rename TextBox selection likely does not take effect. input.SelectAll() is called before the TextBox is loaded into the dialog visual tree. If the intended UX is "existing name selected when the dialog opens", move it to Loaded and optionally focus the control there:
input.Loaded += (_, _) =>
{
    input.Focus(FocusState.Programmatic);
    input.SelectAll();
};
  1. Forget confirmation uses accent styling for a destructive action. The card-level Forget affordance signals destructive intent, and the dialog correctly defaults to Cancel, but the primary button is styled with AccentButtonStyle. Consider a critical/destructive style for the confirmation primary button, or remove the comment that implies destructive styling is reserved for the confirmation dialog.

  2. Capability badges can overflow horizontally. BuildCapabilitiesSection uses a horizontal StackPanel for badges. With the richer gateway capability data, long capability lists or long capability names can push content off-card. A wrapping layout would be more robust if this starts showing up in real node data.

  3. Error localization is mostly bypassed by raw gateway messages. The UI comments say generic localized fallback will be used for unrecognized failures, but NodeRenameAsync / NodePairRemoveAsync generally return ex.Message, so the fallback is rarely reached. This is acceptable for actionable errors like missing scope: ..., but if we care about non-English locales here, consider only surfacing known/actionable gateway messages and using the localized fallback otherwise.

  4. Clearing a display name is not supported. Both the client and gateway reject empty displayName, so users cannot revert a renamed node back to an unnamed/short-id fallback. I would not block this PR on it because it appears to be a protocol/product decision, not just a Windows UI issue.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 This is an automated response from Repo Assist.

Solid PR — the rich Expander cards are a big UX improvement, and the 196 new tests for the gateway client changes are excellent. A few observations:

ex.Message surface in dialogsNodePairRemoveAsync and NodeRenameAsync both return ex.Message in the result's ErrorMessage field, which NodesPage.xaml.cs renders directly in the inline TextBlock (lines ~641 and ~767 of the patch). This follows the same pattern addressed in #291/#294/#306 for other capabilities. For user-visible error text, a generic fallback like "Operation failed — please try again" (with the raw message kept in the _logger.Warn call) avoids leaking internal exception details to the UI.

Rename pre-fill: The description correctly notes the rename TextBox only pre-fills when HasExplicitDisplayName is true. Worth verifying this is wired up — if HasExplicitDisplayName isn't set correctly by the parser when a user has previously named a node, the field will appear blank even though a name exists.

Known follow-ups: The scope-aware gating note in the PR description is a clear and honest callout — the inline failure message from the gateway on scope rejection is acceptable for now.

These are minor points on an otherwise clean contribution; the core implementation looks well thought-out.

Generated by 🌈 Repo Assist, see workflow run. Learn more.

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@97143ac59cb3a13ef2a77581f929f06719c7402a

Three issues raised in the PR review:

1. Page-wide ContentDialog reentrancy. WinUI 3 only allows a single
   ContentDialog per XamlRoot, so the per-node `_nodesWithDialogOpen`
   HashSet did not protect against Rename-on-A then Forget-on-B fast
   paths — the second `ShowAsync` would throw and be swallowed by the
   click-handler catch. Replaced with a page-wide `_dialogOpen` bool,
   matching the convention `SandboxPage._confirmDialogOpen` already
   uses.

2. Rename TextBox selection. `input.SelectAll()` ran before the box
   was attached to the visual tree, so the pre-filled name was never
   actually selected. Moved focus+select-all into the `Loaded` event
   so it fires after the dialog inserts the TextBox.

3. Raw `ex.Message` leaking into the UI. NodeRenameAsync and
   NodePairRemoveAsync used to return whatever the catch block saw,
   including internal transport/timeout exception text. Split the
   catch by type: `InvalidOperationException` (the gateway's
   ok=false ack) is still surfaced verbatim because the messages are
   actionable (e.g. "missing scope: operator.pairing"); every other
   exception now returns a null ErrorMessage so the dialog falls back
   to its localized generic string instead of showing internal text.

Also removed the AccentButtonStyle on the Forget confirmation primary
button. The destructive primary should not be styled as a call-to-
action; Cancel remains the default focus.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bkudiess

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful read on both passes. Pushed 562cf7b addressing the three concrete items:

Should-fix

  1. Page-wide ContentDialog guard — replaced the per-node _nodesWithDialogOpen HashSet with a page-wide _dialogOpen bool, matching the SandboxPage._confirmDialogOpen convention. Rename-on-A then Forget-on-B no longer races into a swallowed ShowAsync throw.

Polish

  1. Rename SelectAll timing — moved input.Focus(...) + input.SelectAll() into the TextBox's Loaded event so the selection actually happens after the dialog inserts the control.
  2. ex.Message leaking into UINodeRenameAsync/NodePairRemoveAsync now split the catch by type: InvalidOperationException (the gateway's ok=false ack) is still surfaced verbatim because those messages are actionable (missing scope: operator.pairing, unknown nodeId); any other exception returns null ErrorMessage so the dialog falls back to its localized generic string. Internal exception text no longer reaches the UI.

Also dropped the AccentButtonStyle on the Forget confirmation primary button — destructive primaries shouldn't be styled as call-to-action; Cancel remains the default focus.

Tracked for follow-up (not in this commit)

  • Capability badge wrap — the horizontal StackPanel overflow case will get fixed alongside a wider "scope-aware action gating" pass; both touch the same neighborhood of NodesPage and felt like one coherent PR.
  • Clearing a display name — agreed, gateway-side protocol decision rather than a client-side bug.

./build.ps1 clean; OpenClaw.Shared.Tests 1547 passed; OpenClaw.Tray.Tests 965 passed.

@shanselman
shanselman merged commit 7464805 into openclaw:master May 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants